/*
 * Copyright (C) from 2022 The Play Framework Contributors <https://github.com/playframework>, 2011-2021 Lightbend Inc. <https://www.lightbend.com>
 */

package play.api.libs

import java.io.File
import java.io.IOException
import java.lang.ref.Reference
import java.nio.file._
import java.nio.file.{ Files => JFiles }
import java.nio.file.attribute.BasicFileAttributes
import java.time.Clock
import java.time.Instant
import java.util.stream

import scala.concurrent.duration._
import scala.concurrent.Future
import scala.language.implicitConversions
import scala.util.Failure
import scala.util.Try

import com.google.common.base.FinalizablePhantomReference
import com.google.common.base.FinalizableReferenceQueue
import com.google.common.collect.Sets
import jakarta.inject.Inject
import jakarta.inject.Provider
import jakarta.inject.Singleton
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.actor.Cancellable
import org.slf4j.LoggerFactory
import play.api.inject.ApplicationLifecycle
import play.api.Configuration

/**
 * FileSystem utilities.
 */
object Files {
  lazy val logger = LoggerFactory.getLogger("play.api.libs.Files")

  /**
   * Logic for creating a temporary file. Users should try to clean up the
   * file themselves, but this TemporaryFileCreator implementation may also
   * try to clean up any leaked files, e.g. when the Application or JVM stops.
   */
  trait TemporaryFileCreator {

    /**
     * Creates a temporary file.
     *
     * @param prefix the prefix of the file.
     * @param suffix the suffix of the file
     * @return the newly created temporary file.
     */
    def create(prefix: String = "", suffix: String = ""): TemporaryFile

    /**
     * Creates a temporary file from an already existing file.
     *
     * @param path the existing temp file path
     * @return a Temporary file wrapping the existing file.
     */
    def create(path: Path): TemporaryFile

    /**
     * Deletes the temporary file.
     *
     * @param file the temporary file to be deleted.
     * @return the boolean value of the FS delete operation, or an throwable.
     */
    def delete(file: TemporaryFile): Try[Boolean]

    /**
     * @return the Java version for the temporary file creator.
     */
    def asJava: play.libs.Files.TemporaryFileCreator = new play.libs.Files.DelegateTemporaryFileCreator(this)
  }

  trait TemporaryFile {
    def path: Path

    @deprecated("Use path rather than file", "2.6.0")
    def file: java.io.File

    def temporaryFileCreator: TemporaryFileCreator

    /**
     * Copy the file to the specified path destination and, if the destination exists, decide if replace it
     * based on the `replace` parameter.
     *
     * @param to the destination file.
     * @param replace if it should replace an existing file.
     */
    def copyTo(to: java.io.File, replace: Boolean = false): Path = copyTo(to.toPath, replace)

    /**
     * Copy the file to the specified path destination and, if the destination exists, decide if replace it
     * based on the `replace` parameter.
     *
     * @param to the path destination.
     * @param replace if it should replace an existing file.
     */
    def copyTo(to: Path, replace: Boolean): Path = {
      val destination =
        try
          if (replace) JFiles.copy(path, to, StandardCopyOption.REPLACE_EXISTING)
          else if (!to.toFile.exists()) JFiles.copy(path, to)
          else to
        catch {
          case _: FileAlreadyExistsException => to
        }

      destination
    }

    /**
     * Move the file to the specified destination [[java.io.File]]. In some cases, the source and destination file
     * may point to the same `inode`. See the documentation for [[java.nio.file.Files.move()]] to see more details.
     *
     * @param to the path to the destination file
     * @param replace true if an existing file should be replaced, false otherwise.
     * @deprecated Since 2.8.0. Renamed to [[moveTo()]].
     */
    @deprecated("Renamed to moveTo", "2.8.0")
    def moveFileTo(to: java.io.File, replace: Boolean = false): Path = {
      moveFileTo(to.toPath, replace)
    }

    /**
     * Move the file using a [[java.nio.file.Path]].
     *
     * @param to the path to the destination file
     * @param replace true if an existing file should be replaced, false otherwise.
     * @deprecated Since 2.8.0. Renamed to [[moveTo()]].
     */
    @deprecated("Renamed to moveTo", "2.8.0")
    def moveFileTo(to: Path, replace: Boolean): Path = moveTo(to, replace)

    /**
     * Move the file to the specified destination [[java.io.File]]. In some cases, the source and destination file
     * may point to the same `inode`. See the documentation for [[java.nio.file.Files.move()]] to see more details.
     *
     * @param to the path to the destination file
     * @param replace true if an existing file should be replaced, false otherwise.
     */
    def moveTo(to: java.io.File, replace: Boolean = false): Path = {
      moveTo(to.toPath, replace)
    }

    /**
     * Move the file using a [[java.nio.file.Path]].
     *
     * @param to the path to the destination file
     * @param replace true if an existing file should be replaced, false otherwise.
     */
    def moveTo(to: Path, replace: Boolean): Path = {
      val destination =
        try {
          if (replace)
            JFiles.move(path, to, StandardCopyOption.REPLACE_EXISTING)
          else if (!to.toFile.exists())
            JFiles.move(path, to)
          else to
        } catch {
          case ex: FileAlreadyExistsException => to
        }

      destination
    }

    /**
     * Attempts to move source to target atomically and falls back to a non-atomic move if it fails.
     *
     * This always tries to replace existent files. Since it is platform dependent if atomic moves replaces
     * existent files or not, considering that it will always replaces, makes the API more predictable.
     *
     * @param to the path to the destination file
     * @deprecated Since 2.8.0. Renamed to [[atomicMoveWithFallback()]].
     */
    @deprecated("Renamed to atomicMoveWithFallback", "2.8.0")
    def atomicMoveFileWithFallback(to: File): Path = atomicMoveFileWithFallback(to.toPath)

    /**
     * Attempts to move source to target atomically and falls back to a non-atomic move if it fails.
     *
     * This always tries to replace existent files. Since it is platform dependent if atomic moves replaces
     * existent files or not, considering that it will always replaces, makes the API more predictable.
     *
     * @param to the path to the destination file
     * @deprecated Since 2.8.0. Renamed to [[atomicMoveWithFallback()]].
     */
    // see https://github.com/apache/kafka/blob/d345d53/clients/src/main/java/org/apache/kafka/common/utils/Utils.java#L608-L626
    @deprecated("Renamed to atomicMoveWithFallback", "2.8.0")
    def atomicMoveFileWithFallback(to: Path): Path = atomicMoveWithFallback(to)

    /**
     * Attempts to move source to target atomically and falls back to a non-atomic move if it fails.
     *
     * This always tries to replace existent files. Since it is platform dependent if atomic moves replaces
     * existent files or not, considering that it will always replaces, makes the API more predictable.
     *
     * @param to the path to the destination file
     */
    def atomicMoveWithFallback(to: File): Path = atomicMoveWithFallback(to.toPath)

    /**
     * Attempts to move source to target atomically and falls back to a non-atomic move if it fails.
     *
     * This always tries to replace existent files. Since it is platform dependent if atomic moves replaces
     * existent files or not, considering that it will always replaces, makes the API more predictable.
     *
     * @param to the path to the destination file
     */
    // see https://github.com/apache/kafka/blob/d345d53/clients/src/main/java/org/apache/kafka/common/utils/Utils.java#L608-L626
    def atomicMoveWithFallback(to: Path): Path = {
      val destination =
        try {
          JFiles.move(path, to, StandardCopyOption.ATOMIC_MOVE)
        } catch {
          case outer: IOException =>
            try {
              val p = JFiles.move(path, to, StandardCopyOption.REPLACE_EXISTING)
              logger.debug(
                s"Non-atomic move of $path to $to succeeded after atomic move failed due to ${outer.getMessage}"
              )
              p
            } catch {
              case inner: IOException =>
                inner.addSuppressed(outer)
                throw inner
            }
        }

      destination
    }
  }

  /**
   * Creates temporary folders inside a single temporary folder. deleting all files on a
   * successful application stop.  Note that this will not clean up the filesystem if the
   * application / JVM terminates abnormally.
   */
  @Singleton
  class DefaultTemporaryFileCreator @Inject() (
      applicationLifecycle: ApplicationLifecycle,
      temporaryFileReaper: TemporaryFileReaper,
      conf: Configuration
  ) extends TemporaryFileCreator {
    private val logger = play.api.Logger(this.getClass)
    private val frq    = new FinalizableReferenceQueue()

    // Much of the PhantomReference implementation is taken from
    // the Google Guava documentation example
    //
    // https://google.github.io/guava/releases/19.0/api/docs/com/google/common/base/FinalizableReferenceQueue.html
    // Keeping references ensures that the FinalizablePhantomReference itself is not garbage-collected.
    private val references = Sets.newConcurrentHashSet[Reference[TemporaryFile]]()

    private val TempDirectoryPrefix        = "playtemp"
    private var TempFolderCreated: Boolean = false
    private lazy val playTempFolder: Path  = {
      val dir = Paths.get(conf.get[String]("play.temporaryFile.dir"))
      JFiles.createDirectories(dir) // make sure dir exists, otherwise createTempDirectory fails
      val tmpFolder = JFiles.createTempDirectory(dir, TempDirectoryPrefix)
      TempFolderCreated = true
      temporaryFileReaper.updateTempFolder(tmpFolder)
      tmpFolder
    }

    override def create(prefix: String, suffix: String): TemporaryFile = {
      JFiles.createDirectories(playTempFolder)
      val tempFile = JFiles.createTempFile(playTempFolder, prefix, suffix)
      createReference(new DefaultTemporaryFile(tempFile, this))
    }

    override def create(path: Path): TemporaryFile = {
      createReference(new DefaultTemporaryFile(path, this))
    }

    private def createReference(tempFile: TemporaryFile) = {
      val path      = tempFile.path
      val reference =
        new FinalizablePhantomReference[TemporaryFile](tempFile, frq) {
          override def finalizeReferent(): Unit = {
            references.remove(this)
            deletePath(path)
          }
        }
      references.add(reference)
      tempFile
    }

    override def delete(tempFile: TemporaryFile): Try[Boolean] = {
      deletePath(tempFile.path)
    }

    private def deletePath(path: Path): Try[Boolean] = {
      logger.debug(s"deletePath: deleting = $path")
      Try(JFiles.deleteIfExists(path)).recoverWith {
        case e: Exception =>
          logger.error(s"Cannot delete $path", e)
          Failure(e)
      }
    }

    /**
     * A temporary file hold a reference to a real path, and will delete
     * it when the reference is garbage collected.
     */
    class DefaultTemporaryFile private[DefaultTemporaryFileCreator] (
        val path: Path,
        val temporaryFileCreator: TemporaryFileCreator
    ) extends TemporaryFile {
      def file: File = path.toFile
    }

    /**
     * Application stop hook which deletes the temporary folder recursively (including subfolders).
     */
    applicationLifecycle.addStopHook { () =>
      Future.successful {
        if (TempFolderCreated && JFiles.isDirectory(playTempFolder)) {
          JFiles.walkFileTree(
            playTempFolder,
            new SimpleFileVisitor[Path] {
              override def visitFile(path: Path, attrs: BasicFileAttributes): FileVisitResult = {
                logger.debug(s"stopHook: Removing leftover temporary file $path from $playTempFolder")
                deletePath(path)
                FileVisitResult.CONTINUE
              }

              override def postVisitDirectory(path: Path, exc: IOException): FileVisitResult = {
                deletePath(path)
                FileVisitResult.CONTINUE
              }
            }
          )
        }
        frq.close()
      }
    }
  }

  trait TemporaryFileReaper {
    def updateTempFolder(folder: Path): Unit
  }

  @Singleton
  class DefaultTemporaryFileReaper @Inject() (actorSystem: ActorSystem, config: TemporaryFileReaperConfiguration)
      extends TemporaryFileReaper {
    private val logger                           = play.api.Logger(this.getClass)
    private val blockingDispatcherName           = "play.pekko.blockingIoDispatcher"
    private val blockingExecutionContext         = actorSystem.dispatchers.lookup(blockingDispatcherName)
    private var playTempFolder: Option[Path]     = None
    private var cancellable: Option[Cancellable] = None

    // Use an overridable clock here so we can swap it out for testing.
    val clock: Clock = Clock.systemUTC()

    // Check that the reaper got a successful reference to the scheduler task
    def enabled: Boolean = cancellable.nonEmpty

    override def updateTempFolder(folder: Path): Unit = {
      playTempFolder = Option(folder)
    }

    def secondsAgo: Instant = clock.instant().minusSeconds(config.olderThan.toSeconds)

    def reap(): Future[Seq[Path]] = {
      logger.debug(s"reap: reaping old files from $playTempFolder")
      Future {
        playTempFolder
          .map { f =>
            import scala.jdk.StreamConverters._

            val directoryStream: stream.Stream[Path] = JFiles.list(f)

            try {
              val reaped = directoryStream
                .filter(p => {
                  val lastModifiedTime = JFiles.getLastModifiedTime(p).toInstant
                  lastModifiedTime.isBefore(secondsAgo)
                })
                .toScala(List)

              reaped.foreach(delete)
              reaped
            } finally {
              directoryStream.close()
            }
          }
          .getOrElse(Seq.empty)
      }(using blockingExecutionContext)
    }

    def delete(path: Path): Unit = {
      logger.debug(s"delete: deleting $path")
      try JFiles.deleteIfExists(path)
      catch {
        case e: Exception =>
          logger.error(s"Cannot delete $path", e)
      }
    }

    private[play] def disable(): Unit = {
      cancellable.foreach(_.cancel())
    }

    if (config.enabled) {
      import config._
      playTempFolder match {
        case Some(folder) =>
          logger.debug(s"Reaper enabled on $folder, starting in $initialDelay with $interval intervals")
        case None =>
          logger.debug(
            s"Reaper enabled but no temp folder has been created yet, starting in $initialDelay with $interval intervals"
          )
      }

      cancellable = Some(
        actorSystem.scheduler.scheduleAtFixedRate(initialDelay, interval) { () => reap() }(using actorSystem.dispatcher)
      )
    }
  }

  /**
   * Configuration for the TemporaryFileReaper.
   *
   * @param enabled true if the reaper is enabled, false otherwise.  Default is false.
   * @param olderThan the period after which the file is considered old. Default 5 minutes.
   * @param initialDelay the initial delay after application start when the reaper first run.  Default 5 minutes.
   * @param interval the duration after the initial run during which the reaper will scan for files it can remove.  Default 5 minutes.
   */
  case class TemporaryFileReaperConfiguration(
      enabled: Boolean = false,
      olderThan: FiniteDuration = 5.minutes,
      initialDelay: FiniteDuration = 5.minutes,
      interval: FiniteDuration = 5.minutes
  )

  object TemporaryFileReaperConfiguration {
    def fromConfiguration(config: Configuration): TemporaryFileReaperConfiguration = {
      def duration(key: String): FiniteDuration = {
        Duration(config.get[String](key)) match {
          case d: FiniteDuration if d.isFinite =>
            d
          case _ =>
            throw new IllegalStateException(s"Only finite durations are allowed for $key")
        }
      }

      val enabled      = config.get[Boolean]("play.temporaryFile.reaper.enabled")
      val olderThan    = duration("play.temporaryFile.reaper.olderThan")
      val initialDelay = duration("play.temporaryFile.reaper.initialDelay")
      val interval     = duration("play.temporaryFile.reaper.interval")

      TemporaryFileReaperConfiguration(enabled, olderThan, initialDelay, interval)
    }

    /**
     * For calling from Java.
     */
    def createWithDefaults() = apply()

    @Singleton
    @deprecated(
      "On JDK8 and earlier, Class.getSimpleName on doubly nested Scala classes throws an exception. Use Files.TemporaryFileReaperConfigurationProvider instead. See https://github.com/scala/bug/issues/2034.",
      "2.6.14"
    )
    class TemporaryFileReaperConfigurationProvider @Inject() (configuration: Configuration)
        extends Provider[TemporaryFileReaperConfiguration] {
      lazy val get: TemporaryFileReaperConfiguration = fromConfiguration(configuration)
    }
  }

  @Singleton
  class TemporaryFileReaperConfigurationProvider @Inject() (configuration: Configuration)
      extends Provider[TemporaryFileReaperConfiguration] {
    lazy val get: TemporaryFileReaperConfiguration = TemporaryFileReaperConfiguration.fromConfiguration(configuration)
  }

  /**
   * Creates temporary folders using java.nio.file.Files.createTempFile.
   *
   * Files created by this method will not be cleaned up with the application
   * or JVM stops.
   */
  object SingletonTemporaryFileCreator extends TemporaryFileCreator {
    override def create(prefix: String, suffix: String): TemporaryFile = {
      val file = JFiles.createTempFile(prefix, suffix)
      new SingletonTemporaryFile(file, this)
    }

    override def create(path: Path): TemporaryFile = {
      new SingletonTemporaryFile(path, this)
    }

    override def delete(tempFile: TemporaryFile): Try[Boolean] = {
      Try(JFiles.deleteIfExists(tempFile.path))
    }

    class SingletonTemporaryFile private[SingletonTemporaryFileCreator] (
        val path: Path,
        val temporaryFileCreator: TemporaryFileCreator
    ) extends TemporaryFile {
      def file: File = path.toFile
    }
  }

  /**
   * Utilities to manage temporary files.
   */
  object TemporaryFile {

    /**
     * Implicitly converts a [[TemporaryFile]] to a plain old [[java.io.File]].
     */
    implicit def temporaryFileToFile(tempFile: TemporaryFile): java.io.File = tempFile.path.toFile

    /**
     * Implicitly converts a [[TemporaryFile]] to a plain old [[java.nio.file.Path]] instance.
     */
    implicit def temporaryFileToPath(tempFile: TemporaryFile): Path = tempFile.path

    /**
     * Create a new temporary file.
     *
     * Example:
     * {{{
     * val tempFile = TemporaryFile(prefix = "uploaded")
     * }}}
     *
     * @param creator the temporary file creator
     * @param prefix The prefix used for the temporary file name.
     * @param suffix The suffix used for the temporary file name.
     * @return A temporary file instance.
     */
    @deprecated("Use temporaryFileCreator.create", "2.6.0")
    def apply(creator: TemporaryFileCreator, prefix: String = "", suffix: String = ""): TemporaryFile = {
      creator.create(prefix, suffix)
    }
  }
}
